// This is an open source non-commercial project. Dear PVS-Studio, please check it.
// PVS-Studio Static Code Analyzer for C, C++ and C#: http://www.viva64.com

// ReSharper disable CheckNamespace
// ReSharper disable CommentTypo
// ReSharper disable IdentifierTypo
// ReSharper disable InconsistentNaming
// ReSharper disable LocalizableElement
// ReSharper disable StringLiteralTypo
// ReSharper disable UnusedMember.Global
// ReSharper disable UseNameofExpression

/*
 * Ars Magna project, http://arsmagna.ru
 */

#region Using directives

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Principal;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.RegularExpressions;

using Microsoft.AspNetCore.Html;

#endregion

#nullable enable

namespace AM.HtmlTags;

public class HtmlTag : ITagSource, IHtmlContent
{
    public static HtmlTag Empty() => new HtmlTag ("span").Render (false);

    public static HtmlTag Placeholder() => new HtmlTag().NoTag();

    private static readonly Regex RxWhiteSpace = new ("[ ]+");
    private const string CssClassAttribute = "class";
    private const string CssStyleAttribute = "style";
    private const string DataPrefix = "data-";
    private static string _metadataSuffix = "__";

    private static readonly HashSet<string> _voidElementTags = new (StringComparer.OrdinalIgnoreCase)
    {
        "area",
        "base",
        "br",
        "col",
        "hr",
        "img",
        "input",
        "link",
        "meta",
        "param",
        "command",
        "keygen",
        "source",
    };


    public static void UseMetadataSuffix (string suffix)
    {
        _metadataSuffix = suffix;
    }

    public static string MetadataAttribute => DataPrefix + _metadataSuffix;

    private readonly List<HtmlTag> _children = new ();
    private readonly HashSet<string> _cssClasses = new ();
    private readonly IDictionary<string, string> _customStyles = new Dictionary<string, string>();

    private readonly Cache<string, HtmlAttribute> _htmlAttributes = new (key => null);

    private readonly Cache<string, object> _metaData = new ();
    private string _innerText = String.Empty;
    private bool _shouldRender = true;
    private string _tag;
    private bool _ignoreOpeningTag;
    private bool _ignoreClosingTag;
    private bool _isAuthorized = true;

    private HtmlTag()
    {
    }

    public HtmlTag (string tag) : this()
    {
        _tag = tag;
        _ignoreClosingTag = _voidElementTags.Contains (_tag);
    }

    public HtmlTag (string tag, Action<HtmlTag> configure)
        : this (tag)
    {
        configure (this);
    }

    public HtmlTag (string tag, HtmlTag parent) : this (tag)
    {
        if (parent != null)
        {
            Parent = parent;
            parent.Append (this);
        }
    }

    private bool _encodeInnerText = true;

    /// <summary>
    /// The sibling tag that immediately follows the current tag.
    /// Setting this value will remove any existing value. Use <see cref="After(HtmlTag)"/> if you wish to insert a new sibling before any existing sibling.
    /// </summary>
    public HtmlTag Next { get; set; }

    /// <summary>
    /// Inserts a sibling tag immediately after the current tag. Any existing sibling will follow the inserted tag.
    /// </summary>
    /// <param name="nextTag">The tag to add as a sibling</param>
    /// <returns>The original tag</returns>
    public HtmlTag After (HtmlTag nextTag)
    {
        nextTag.Next = Next;
        Next = nextTag;
        return this;
    }

    /// <summary>
    /// Returns the sibling tag that immediately follows the current tag. Same as <see cref="Next" />.
    /// </summary>
    /// <returns></returns>
    public HtmlTag After() => Next;

    public IList<HtmlTag> Children => _children;

    public HtmlTag Parent { get; private set; }

    IEnumerable<HtmlTag> ITagSource.AllTags()
    {
        yield return this;
    }

    public string TagName() => _tag;

    public HtmlTag TagName (string tag)
    {
        _tag = tag.ToLower();
        _ignoreClosingTag = _voidElementTags.Contains (_tag);
        return this;
    }

    public HtmlTag FirstChild() => _children.FirstOrDefault();

    public void InsertFirst (HtmlTag tag)
    {
        tag.Parent = this;
        _children.Insert (0, tag);
    }

    public HtmlTag Style (string key, string value)
    {
        _customStyles[key] = value;
        return this;
    }

    public string Style (string key) => _customStyles[key];

    public bool HasStyle (string key) => _customStyles.ContainsKey (key);

    public HtmlTag Id (string id) => Attr ("id", id);

    public string Id() => Attr ("id");

    public HtmlTag Hide() => Style ("display", "none");

    /// <summary>
    /// Creates nested child tags and returns the innermost tag. Use <see cref="Append(string)"/> if you want to return the parent tag.
    /// </summary>
    /// <param name="tagNames">One or more HTML element names separated by a <c>/</c> or <c>></c></param>
    /// <returns>The innermost tag that was newly added</returns>
    public HtmlTag Add (string tagNames)
    {
        var tags = tagNames.ToDelimitedArray ('/', '>');
        return tags.Aggregate (this, (parent, tag) => new HtmlTag (tag, parent));
    }

    /// <summary>
    /// Creates nested child tags and returns the innermost tag after running <paramref name="configuration"/> on it. Use <see cref="Append(string, Action{HtmlTag})"/> if you want to return the parent tag.
    /// </summary>
    /// <param name="tagNames">One or more HTML element names separated by a <c>/</c> or <c>></c></param>
    /// <param name="configuration">Modifications to perform on the newly added innermost tag</param>
    /// <returns>The innermost tag that was newly added</returns>
    public HtmlTag Add (string tagNames, Action<HtmlTag> configuration)
    {
        var element = Add (tagNames);
        configuration (element);

        return element;
    }

    /// <summary>
    /// Creates a tag of <typeparamref name="T"/> and adds it as a child. Returns the created child tag.
    /// </summary>
    /// <typeparam name="T">The type of <see cref="HtmlTag"/> to create</typeparam>
    /// <returns>The created child tag</returns>
    public T Add<T>() where T : HtmlTag, new()
    {
        var child = new T { Parent = this };
        _children.Add (child);
        return child;
    }

    /// <summary>
    /// Adds the given tag as the last child of the parent, and returns the parent.
    /// </summary>
    /// <param name="child">The tag to add as a child of the parent.</param>
    /// <returns>The parent tag</returns>
    public HtmlTag Append (HtmlTag child)
    {
        child.Parent = this;
        _children.Add (child);
        return this;
    }

    /// <summary>
    /// Creates nested child tags and returns the tag on which the method was called. Use <see cref="Add(string)"/> if you want to return the innermost tag.
    /// </summary>
    /// <param name="tagNames">One or more HTML element names separated by a <c>/</c> or <c>></c></param>
    /// <returns>The instance on which the method was called (the parent of the new tags)</returns>
    public HtmlTag Append (string tagNames)
    {
        Add (tagNames);
        return this;
    }

    /// <summary>
    /// Adds a LiteralTag of unencoded html to this HtmlTag
    /// </summary>
    /// <param name="html"></param>
    /// <returns></returns>
    public HtmlTag AppendHtml (string html) => Append (new LiteralTag (html));

    /// <summary>
    /// Creates nested child tags, runs <paramref name="configuration"/> on the innermost tag, and returns the tag on which the method was called. Use <see cref="Add(string, Action{HtmlTag})"/> if you want to return the innermost tag.
    /// </summary>
    /// <param name="tagNames"></param>
    /// <param name="configuration"></param>
    /// <returns>The parent tag</returns>
    public HtmlTag Append (string tagNames, Action<HtmlTag> configuration)
    {
        Add (tagNames, configuration);
        return this;
    }

    /// <summary>
    /// Adds all tags from <paramref name="tagSource"/> as children of the current tag. Returns the parent tag.
    /// </summary>
    /// <param name="tagSource">The source of tags to add as children.</param>
    /// <returns>The parent tag</returns>
    public HtmlTag Append (ITagSource tagSource)
    {
        tagSource.AllTags().Each (x =>
        {
            x.Parent = this;
            _children.Add (x);
        });
        return this;
    }

    /// <summary>
    /// Adds a sequence of tags as children of the current tag. Returns the parent tag.
    /// </summary>
    /// <param name="tags">A sequence of tags to add as children.</param>
    /// <returns>The parent tag</returns>
    public HtmlTag Append (IEnumerable<HtmlTag> tags)
    {
        tags.Each (x =>
        {
            x.Parent = this;
            _children.Add (x);
        });
        return this;
    }

    /// <summary>
    /// Stores a value in an HTML5 custom data attribute
    /// </summary>
    /// <param name="key">The name of the data attribute. Will have "data-" prepended when rendered.</param>
    /// <param name="value">The value to store. Non-string values will be JSON </param>
    /// <returns>The calling tag.</returns>
    public HtmlTag Data (string key, object value)
    {
        var dataKey = DataPrefix + key;
        if (value == null)
        {
            return RemoveAttr (dataKey);
        }

        _htmlAttributes[dataKey] = new HtmlAttribute (value);
        return this;
    }

    /// <summary>
    /// Modifies an existing reference value stored in an HTML5 custom data
    /// </summary>
    /// <typeparam name="T">The type of the data stored in the given location</typeparam>
    /// <param name="key">The name of the data storage location</param>
    /// <param name="configure">The action to perform on the currently stored value</param>
    /// <returns>The calling tag.</returns>
    public HtmlTag Data<T> (string key, Action<T> configure) where T : class
    {
        var dataKey = DataPrefix + key;
        if (!_htmlAttributes.Has (dataKey))
        {
            return this;
        }

        var value = (T)_htmlAttributes[dataKey].Value;
        configure (value);
        return this;
    }

    /// <summary>
    /// Returns the value stored in HTML5 custom data
    /// </summary>
    /// <param name="key">The name of the data storage location</param>
    /// <returns>The calling tag.</returns>
    public object Data (string key)
    {
        var dataKey = DataPrefix + key;
        return _htmlAttributes.Has (dataKey) ? _htmlAttributes[dataKey].Value : null;
    }

    /// <summary>
    /// Stores multiple JSON-encoded key/value pairs in a the "data-__" attribute. Useful when used with the jquery.metadata plugin
    /// </summary>
    /// <param name="key">The name of the stored value</param>
    /// <param name="value">The value to store</param>
    /// <remarks>You need to configure the the jquery.metadata plugin to read from the data-__ attribute.
    /// Add the following line after you have loaded jquery.metadata.js, but before you use its metadata() method:
    /// <code>
    /// if ($.metadata) {
    ///    $.metadata.setType('attr', 'data-__');
    /// }
    /// </code>
    /// </remarks>
    /// <returns>The calling tag.</returns>
    public HtmlTag MetaData (string key, object value)
    {
        _metaData[key] = value;
        return this;
    }

    /// <summary>
    /// Modifies an existing reference value stored in MetaData
    /// </summary>
    /// <typeparam name="T">The type of the stored value</typeparam>
    /// <param name="key">The name of the stored value</param>
    /// <param name="configure">The action to perform on the currently stored value</param>
    /// <returns>The calling tag.</returns>
    public HtmlTag MetaData<T> (string key, Action<T> configure) where T : class
    {
        if (!_metaData.Has (key))
        {
            return this;
        }

        var value = (T)_metaData[key];
        configure (value);

        return this;
    }

    /// <summary>
    /// Returns the MetaData value stored for a given key.
    /// </summary>
    /// <param name="key">The name of the stored value</param>
    /// <returns>The calling tag.</returns>
    public object MetaData (string key) => !_metaData.Has (key) ? null : _metaData[key];

    public HtmlTag Text (string text)
    {
        _innerText = text;
        return this;
    }

    public string Text() => _innerText;

    public HtmlTag AppendText (string text)
    {
        var textTag = new HtmlTag().NoTag().Text (text);

        return Append (textTag);
    }

    public HtmlTag MergeAttributes (IDictionary<string, string> attributes)
    {
        foreach (var attribute in attributes)
        {
            Attr (attribute.Key, attribute.Value);
        }

        return this;
    }

    public HtmlTag Modify (Action<HtmlTag> action)
    {
        action (this);
        return this;
    }

    public HtmlTag Authorized (bool isAuthorized)
    {
        _isAuthorized = isAuthorized;
        return this;
    }

    public bool Authorized() => _isAuthorized;

    private bool _renderFromTop;

    public HtmlTag RenderFromTop()
    {
        _renderFromTop = true;
        return this;
    }

    public void WriteTo (TextWriter writer, HtmlEncoder encoder)
    {
        WriteHtml (writer, encoder);
    }

    public override string ToString()
    {
        return WillBeRendered()
            ? ToString (new StringWriter(), HtmlEncoder.Default)
            : string.Empty;
    }

    public string ToHtmlString() => ToString();

    public string ToString (TextWriter html, HtmlEncoder encoder)
    {
        var tag = _renderFromTop ? WalkToTop (this) : this;
        tag.WriteHtml (html, encoder);
        return html.ToString();
    }

    private static HtmlTag WalkToTop (HtmlTag htmlTag)
    {
        return htmlTag.Parent == null ? htmlTag : WalkToTop (htmlTag.Parent);
    }

    public bool WillBeRendered() => _shouldRender && _isAuthorized;

    protected virtual void WriteHtml (TextWriter html, HtmlEncoder encoder)
    {
        if (!WillBeRendered())
        {
            return;
        }

        WriteBeginTag (html, encoder);

        WriteContent (html, encoder);

        WriteEndTag (html);

        Next?.WriteHtml (html, encoder);
    }

    protected void WriteBeginTag (TextWriter html, HtmlEncoder encoder)
    {
        if (!HasTag())
        {
            return;
        }

        html.Write ("<");
        html.Write (_tag);

        if (_htmlAttributes.Count > 0)
        {
            _htmlAttributes.Each ((key, attribute) =>
            {
                if (attribute != null)
                {
                    var value = attribute.Value;
                    var stringValue = !(value is string) && key.StartsWith (DataPrefix)
                        ? JsonSerializer.Serialize (value)
                        : value.ToString();
                    RenderAttribute (html, encoder, key, stringValue, attribute.IsEncoded);
                }
                else
                {
                    RenderAttribute (html, encoder, key, null, true);
                }
            });
        }

        if (_cssClasses.Count > 0)
        {
            var classValue = _cssClasses.Join (" ");
            RenderAttribute (html, encoder, CssClassAttribute, classValue, true);
        }

        if (_metaData.Count > 0)
        {
            var metadataValue = JsonSerializer.Serialize (_metaData.Inner);
            RenderAttribute (html, encoder, MetadataAttribute, metadataValue, true);
        }

        if (_customStyles.Count > 0)
        {
            var attValue = _customStyles
                .Select (x => x.Key + ":" + x.Value)
                .ToArray().Join (";");

            RenderAttribute (html, encoder, CssStyleAttribute, attValue, true);
        }


        html.Write (">");
    }

    private void RenderAttribute (TextWriter html, HtmlEncoder encoder, string key, string value,
        bool attributeIsEncoded)
    {
        html.Write (" ");
        html.Write (key);
        html.Write ("=\"");
        if (value != null)
        {
            if (!attributeIsEncoded)
            {
                html.Write (value);
            }
            else
            {
                encoder.Encode (html, value);
            }
        }

        html.Write ("\"");
    }

    private void WriteContent (TextWriter html, HtmlEncoder encoder)
    {
        if (_innerText != null)
        {
            if (_encodeInnerText)
            {
                encoder.Encode (html, _innerText);
            }
            else
            {
                html.Write (_innerText);
            }
        }

        _children.Each (x => x.WriteHtml (html, encoder));
    }

    private void WriteEndTag (TextWriter html)
    {
        if (!HasClosingTag())
        {
            return;
        }

        html.Write ("</");
        html.Write (_tag);
        html.Write (">");
    }


    public HtmlTag Attr (string attribute, object value) => BuildAttr (attribute, value);

    public HtmlTag BooleanAttr (string attribute)
    {
        if (IsCssClassAttr (attribute))
        {
            return BuildAttr (attribute, null);
        }

        _htmlAttributes[attribute] = null;
        return this;
    }

    public HtmlTag UnencodedAttr (string attribute, object value) => BuildAttr (attribute, value, false);

    private HtmlTag BuildAttr (string attribute, object value, bool encode = true)
    {
        if (value == null)
        {
            return RemoveAttr (attribute);
        }

        if (value.Equals (string.Empty) &&
            (IsCssClassAttr (attribute) || IsCssStyleAttr (attribute) || IsMetadataAttr (attribute)))
        {
            return RemoveAttr (attribute);
        }

        if (IsCssClassAttr (attribute))
        {
            AddClass (value.ToString());
        }
        else
        {
            _htmlAttributes[attribute] = new HtmlAttribute (value.ToString(), encode);
        }

        return this;
    }

    private static bool IsCssClassAttr (string attribute) =>
        attribute.Equals (CssClassAttribute, StringComparison.OrdinalIgnoreCase);

    private static bool IsCssStyleAttr (string attribute) =>
        attribute.Equals (CssStyleAttribute, StringComparison.OrdinalIgnoreCase);

    private static bool IsMetadataAttr (string attribute) =>
        attribute.Equals (MetadataAttribute, StringComparison.OrdinalIgnoreCase);

    public string Attr (string attribute)
    {
        var attrVal = _htmlAttributes[attribute];
        return attrVal?.ToString() ?? string.Empty;
    }

    public HtmlTag RemoveAttr (string attribute)
    {
        if (IsCssClassAttr (attribute))
        {
            _cssClasses.Clear();
        }
        else if (IsCssStyleAttr (attribute))
        {
            _customStyles.Clear();
        }
        else if (IsMetadataAttr (attribute))
        {
            _metaData.ClearAll();
        }
        else
        {
            _htmlAttributes.Remove (attribute);
        }

        return this;
    }

    public HtmlTag Render (bool shouldRender)
    {
        _shouldRender = shouldRender;
        return this;
    }

    public bool Render() => _shouldRender;

    /// <summary>
    /// Adds one or more classes (separated by spaces) to the tag
    /// </summary>
    /// <param name="className">Valid CSS class name, JSON object, JSON array, or multiple valid CSS class names separated by spaces</param>
    /// <returns>The tag for method chaining</returns>
    /// <exception cref="System.ArgumentException">One or more CSS class names were invalid (contained invalid characters)</exception>
    public HtmlTag AddClass (string className)
    {
        IEnumerable<string> classes = ParseClassName (className);
        foreach (string parsedClass in classes)
        {
            if (!CssClassNameValidator.IsValidClassName (parsedClass))
            {
                throw new ArgumentException ($"CSS class names is not valid. Problem class was '{className}'",
                    nameof (className));
            }

            _cssClasses.Add (parsedClass);
        }

        return this;
    }

    /// <summary>
    /// Parses a string which contains class name or multiple class names.
    /// </summary>
    /// <param name="className">A string which contains class(-es)</param>
    /// <returns>The list of classes</returns>
    private static IEnumerable<string> ParseClassName (string className)
    {
        IEnumerable<string> classes;
        if (CssClassNameValidator.IsJsonClassName (className))
        {
            classes = new List<string> { className };
        }
        else
        {
            classes = RxWhiteSpace.Split (className)
                .Where (c => !string.IsNullOrWhiteSpace (c));
        }

        return classes;
    }

    public HtmlTag AddClasses (params string[] classes) => AddClasses ((IEnumerable<string>)classes);

    public HtmlTag AddClasses (IEnumerable<string> classes)
    {
        foreach (var cssClass in classes)
        {
            AddClass (cssClass);
        }

        return this;
    }

    public IEnumerable<string> GetClasses() => _cssClasses;

    public bool HasClass (string className) => _cssClasses.Contains (className);

    public HtmlTag RemoveClass (string className)
    {
        _cssClasses.Remove (className);
        return this;
    }

    public bool HasMetaData (string key) => _metaData.Has (key);

    public string Title() => Attr ("title");

    public HtmlTag Title (string title) => Attr ("title", title);

    public bool HasAttr (string key)
    {
        if (IsCssClassAttr (key))
        {
            return _cssClasses.Count > 0;
        }

        if (IsCssStyleAttr (key))
        {
            return _customStyles.Count > 0;
        }

        if (IsMetadataAttr (key))
        {
            return _metaData.Count > 0;
        }

        return _htmlAttributes.Has (key);
    }

    public bool IsInputElement() => _tag == "input" || _tag == "select" || _tag == "textarea";

    public void ReplaceChildren (params HtmlTag[] tags)
    {
        Children.Clear();
        tags.Each (t =>
        {
            t.Parent = this;
            Children.Add (t);
        });
    }

    /// <summary>
    /// Specify that the tag should render only its children and not itself.
    /// Used for declaring container/placeholder tags that should not affect the final markup.
    /// </summary>
    /// <returns></returns>
    public HtmlTag NoTag()
    {
        _ignoreOpeningTag = true;
        _ignoreClosingTag = true;
        return this;
    }

    public HtmlTag NoClosingTag()
    {
        _ignoreClosingTag = true;
        return this;
    }

    public HtmlTag UseClosingTag()
    {
        _ignoreClosingTag = false;
        return this;
    }

    /// <summary>
    /// Get whether or not to render the tag itself or just the children of the tag.
    /// </summary>
    /// <returns></returns>
    public bool HasTag() => !_ignoreOpeningTag;

    public bool HasClosingTag() => !_ignoreClosingTag;

    public HtmlTag WrapWith (string tag)
    {
        var wrapper = new HtmlTag (tag);
        wrapper.Append (this);

        // Copies visibility and authorization from inner tag
        wrapper.Render (Render());
        wrapper.Authorized (Authorized());

        return wrapper;
    }

    public HtmlTag WrapWith (HtmlTag wrapper)
    {
        wrapper.InsertFirst (this);
        return wrapper;
    }

    public HtmlTag VisibleForRoles (IPrincipal principal, params string[] roles) =>
        Render (roles.Any (principal.IsInRole));

    public HtmlTag Encoded (bool encodeInnerText)
    {
        _encodeInnerText = encodeInnerText;
        return this;
    }

    public bool Encoded() => _encodeInnerText;

    private class HtmlAttribute
    {
        public HtmlAttribute (object value)
            : this (value, true)
        {
        }

        public HtmlAttribute (object value, bool isEncoded)
        {
            Value = value;
            IsEncoded = isEncoded;
        }

        public object Value { get; }
        public bool IsEncoded { get; }

        public override string ToString() => Value?.ToString() ?? string.Empty;
    }

    public HtmlTag ForChild (string tagName)
    {
        return ((ITagSource)this).AllTags().First (child => child.TagName().SameString (tagName));
    }

    /// <summary>
    ///
    /// </summary>
    /// <param name="defaultText"></param>
    /// <returns></returns>
    /// <exception cref="InvalidOperationException"></exception>
    public HtmlTag TextIfEmpty (string defaultText)
    {
        if (TagName().SameString ("input"))
        {
            throw new InvalidOperationException (
                "You are attempting to set the inner text on an INPUT tag. If you wanted a textarea, call MultilineMode() first.");
        }

        if (string.IsNullOrEmpty (Text()))
        {
            Text (defaultText);
        }

        return this;
    }

    /// <summary>
    ///
    /// </summary>
    /// <param name="name"></param>
    /// <returns></returns>
    public HtmlTag Name (string name) => Attr ("name", name);

    /// <summary>
    ///
    /// </summary>
    /// <param name="value"></param>
    /// <returns></returns>
    public HtmlTag Value (string value) => Attr ("value", value);

    /// <summary>
    ///
    /// </summary>
    /// <returns></returns>
    public string Value() => Attr ("value");
}
